Skip to content

fix: resolve dynamic import() and bare require/module in TypeScript configs - #5686

Open
gololdf1sh wants to merge 1 commit into
codeceptjs:4.xfrom
gololdf1sh:fix/ts-config-dynamic-import
Open

fix: resolve dynamic import() and bare require/module in TypeScript configs#5686
gololdf1sh wants to merge 1 commit into
codeceptjs:4.xfrom
gololdf1sh:fix/ts-config-dynamic-import

Conversation

@gololdf1sh

Copy link
Copy Markdown
Contributor

Motivation/Description of the PR

A .ts config is not executed as TypeScript — lib/config.js transpiles it to a temp .mjs and transpileTypeScript walks and transpiles its import tree along with it. Two gaps in that walk make perfectly ordinary TypeScript fail, and both only surface at runtime, with errors that point away from the real cause.

1. Dynamic import() is invisible to the transpiler. The dependency scan and the rewrite pass matched from '...' and require('...') only. A module reached through await import('./module') was therefore never emitted, and the specifier survived transpilation still pointing at an extensionless .ts path:

// codecept.conf.ts
export const config = {
  // ...
  teardownAll: async () => {
    const { globalTeardown } = await import('./src/lifecycle/globalTeardown')
    await globalTeardown()
  },
}
ERR_MODULE_NOT_FOUND: Cannot find module '/…/src/lifecycle/globalTeardown'
imported from /…/codecept.conf.<pid>.<hash>.temp.mjs

The same import written statically at the top of the config works, which makes the failure look like an ESM-vs-tsx resolution problem in the runner rather than a gap in the transpiler. In our suite this cost a real workaround: both lifecycle hooks shelled out to execSync('npx tsx <script>') for a year, with a code comment blaming the worker ESM context.

Static and dynamic specifiers now share one resolver, so both follow identical ESM resolution — path aliases, index.ts, .js-that-is-really-.ts, and the extension-append fallback included.

2. A bare require / module identifier gets no CommonJS shim. Shim injection was gated on /\brequire\s*\(/ and /\b(module\.exports|exports\.)/, so the standard entrypoint idiom matched neither:

// a module that doubles as a CLI script
if (require.main === module) {
  main()
}
ReferenceError: require is not defined in ES module scope, you can use import instead

Detection now counts bare require / module identifiers. Two refinements keep it from over-firing:

  • quoted occurrences are ignored, so import { createRequire } from 'module' is not mistaken for a reference;
  • a file that declares its own binding is skipped. This also fixes a pre-existing failure mode: a file doing const require = createRequire(import.meta.url) and calling require('x') used to get a second const require injected, i.e. a SyntaxError from the shim itself.

Verification

New fixture test/data/typescript-config-dynamic-import/ covers both paths in one realistic shape: a config that dynamically imports a lifecycle module, which in turn statically imports a third file and carries a require.main === module guard. Two unit tests assert the module tree is fully emitted and reachable, that the guarded file imports without throwing, and that the guard stays dormant on import.

Unit suite before this change: 769 passing, 11 pending, 0 failing. After: 771 passing, 11 pending, 0 failing. eslint clean on the touched files.

Also checked against the real-world case that prompted this, a 10-file config import tree in a private suite:

4.x today with this PR
await import('./src/…/globalTeardown') in a .ts config ERR_MODULE_NOT_FOUND resolves, module tree emitted

Applicable helpers:

  • Playwright
  • Puppeteer
  • WebDriver
  • REST
  • FileHelper
  • Appium

Applicable plugins:

  • aiTrace
  • autoDelay
  • autoLogin
  • customLocator
  • pause
  • coverage
  • heal
  • retryFailedStep
  • screenshot
  • selenoid
  • stepTimeout
  • subtitles

Type of change

  • 🔥 Breaking changes
  • 🚀 New functionality
  • 🐛 Bug fix
  • 🧹 Chore
  • 📋 Documentation changes/updates
  • ♨️ Hot fix
  • 🔨 Markdown files fix - not related to source code
  • 💅 Polish code

Checklist:

  • Tests have been added (2 unit tests + fixture in test/unit/utils/typescript_test.js)
  • Documentation has been added (not needed — no public API change)
  • Lint checking (npx eslint lib/utils/typescript.js test/unit/utils/typescript_test.js)
  • Local tests are passed (mocha test/unit --recursive: 771 passing, 0 failing)

…onfigs

A `.ts` config is transpiled to a temp `.mjs` and its import tree is transpiled
with it, but two things were missed, and both surface only at runtime.

Dynamic `import('./module')` was invisible to the transpiler: the dependency
scan and the rewrite pass matched `from '...'` and `require('...')` only, so a
lazily imported module was never emitted and the specifier still pointed at a
`.ts` path — ERR_MODULE_NOT_FOUND. Static and dynamic specifiers now share one
resolver, so both follow the same ESM resolution.

The CommonJS shim was gated on `require(` and `module.exports`, so the standard
`if (require.main === module)` entrypoint idiom got no shim and the transpiled
file threw "require is not defined in ES module scope". Detection now counts
bare `require` / `module` identifiers, ignores quoted occurrences such as
`from 'module'`, and skips files that declare their own binding — which also
stops the shim redeclaring a user's own `const require = createRequire(...)`.

Unit suite: 769 -> 771 passing, 0 failing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant